Skip to content

feat(trace): local storage mode for AI coding sessions - #3617

Open
matiasinsaurralde wants to merge 7 commits into
mainfrom
matias/issue-3582-trace-local-mode
Open

matiasinsaurralde wants to merge 7 commits into
mainfrom
matias/issue-3582-trace-local-mode

Conversation

@matiasinsaurralde

@matiasinsaurralde matiasinsaurralde commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Refs PFM-7675

Summary

Chainloop Trace records an AI coding session and attests it to a control plane on git push. This adds a local storage mode: a repository records its sessions and stores the assembled evidence set on disk, with no control plane, login, or network.

The mode is a committed repository policy in .chainloop.yml:

  • traceStorage - CONTROL_PLANE (default) or LOCAL
  • tracePath - optional location for stored sessions (defaults under the per-clone trace state)
  • traceRequired - replaces the deprecated requireTrace

chainloop trace init --local sets a repository up with no control plane (R-008). In local mode the pre-push hook runs the same push-time assembly and writes each material under its content digest, with a per-session manifest that declares the output unsigned and not an attestation (R-002, R-003, R-006, R-009). trace run honors the mode as well. Secret redaction stays on by default, with a CHAINLOOP_TRACE_NO_REDACT opt-out for a trusted local store (R-004). No control-plane connection is opened in local mode (R-005), and stored sessions are preserved across a trace uninstall.

Implements the design in docs/specs/issue-3582-trace-evidence-export.md.

Refs #3582

Flow

  1. Run trace init --local:
% chainloop trace init --local      
┃ Harnesses to trace in this repository
┃ > ✓ claude-code
┃   • cursor
┃   • opencode


Congratulations, your repository is initialized in local mode

  storage       local (no control plane)
  sessions      stored under .git/chainloop-trace/local-sessions on push
  harnesses     claude-code

What's next

  1. Commit these files so every clone records locally:
       git add .chainloop.yml .claude/settings.json
  2. Start claude-code and write some code
  3. Commit and push as usual. The session evidence is written to disk, with no
     control plane; the push tells you where

Learn more: https://docs.chainloop.dev/ai-sessions
  1. Run Claude:
% claude 

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 Accessing workspace:

 /private/var/folders/f2/x8m881b97f7f3b2c_tgd8ry00000gn/T/tmp.kic78wD1Uu

 Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this folder first.

 Claude Code'll be able to read, edit, and execute files here.

 Security guide

 ❯ No, exit
   Yes, I trust this folder

 Enter to confirm · Esc to cancel
% claude 
▗ ▗   ▖ ▖  Claude Code v2.1.296
           Opus 4.8 with xhigh effort · Claude Team
 ▘▘   ▝▝   /private/var/folders/f2/x8m881b97f7f3b2c_tgd8ry00000gn/T/tmp.kic78wD1Uu

⚠ Your login expires in 2 days · run /login to renew

  Introducing Sonnet 5.5
  Sonnet 5.5 is here, and it's faster and excels at well-scoped tasks. Opus 5.5 is still your default. Switch with /model.
  ⎿  SessionStart:startup says: 

     Chainloop Trace is recording this session in local mode.
     Its evidence will be stored on disk at /var/folders/f2/x8m881b97f7f3b2c_tgd8ry00000gn/T/tmp.kic78wD1Uu/.git/chainloop-trace/local-sessions when you push.
  
           Opus 4.8 with xhigh effort · Claude Team
 ▘▘   ▝▝   /private/var/folders/f2/x8m881b97f7f3b2c_tgd8ry00000gn/T/tmp.kic78wD1Uu

⚠ Your login expires in 2 days · run /login to renew

  Introducing Sonnet 5.5
  Sonnet 5.5 is here, and it's faster and excels at well-scoped tasks. Opus 5.5 is still your default. Switch with /model.
  ⎿  SessionStart:startup says:

     Chainloop Trace is recording this session in local mode.
     Its evidence will be stored on disk at /var/folders/f2/x8m881b97f7f3b2c_tgd8ry00000gn/T/tmp.kic78wD1Uu/.git/chainloop-trace/local-sessions when you push.


❯ create a hello world in go as main.go                                                                                                                                                                                                    

⏺ I'll create the Go hello world file. (This is a one-line request with no external spec or source to capture, so nothing goes into the spec folder.)

⏺ Write(main.go)
  ⎿  Wrote 7 lines to main.go
      1 package main
      2
      3 import "fmt"
      4
      5 func main() {
      6   fmt.Println("Hello, World!")
      7 }

⏺ Created main.go:

  package main

  import "fmt"

  func main() {
          fmt.Println("Hello, World!")
  }

  Run it with go run main.go.

✻ Worked for 8s · done 3:12 PM

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
❯ /exit

  1. Create a local remote and push:
% git add main.go 
% git commit -s -m 'Add main'
INF commit record saved sha=2771434712b37daa0bb8a2e5b6334914f6cfaaf3
[main (root-commit) 2771434] Add main
 1 file changed, 7 insertions(+)
 create mode 100644 main.go
% git push -u origin main  
INF AI coding session evidence stored at /var/folders/f2/x8m881b97f7f3b2c_tgd8ry00000gn/T/tmp.kic78wD1Uu/.git/chainloop-trace/local-sessions sessions=1
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Delta compression using up to 10 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 347 bytes | 347.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To ../remote.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.
  1. Verify the recorded local sessions:
% find .git/chainloop-trace/local-sessions 
.git/chainloop-trace/local-sessions
.git/chainloop-trace/local-sessions/b8c89e2c-9cda-47e2-a326-38d68c9ae0ef
.git/chainloop-trace/local-sessions/b8c89e2c-9cda-47e2-a326-38d68c9ae0ef/materials
.git/chainloop-trace/local-sessions/b8c89e2c-9cda-47e2-a326-38d68c9ae0ef/materials/sha256-0840bca458fa3e72dfe87fdd43b3b731aa88fecaf3cfe33138231b4c5d592d67
.git/chainloop-trace/local-sessions/b8c89e2c-9cda-47e2-a326-38d68c9ae0ef/manifest.json

@chainloop-platform

chainloop-platform Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

AI Session Checks — 🟢 93% · ✅ 0 failing

Avg score Sessions Failing policies Attribution Files Lines Total Duration
🟢 93% 1 ✅ 0 100% AI / 0% Human 23 +1710 / -297 2h3m24s

🟢 93% — 100% AI — ✅ All policies passing

Oct 14, 2026 02:52 UTC · 2h3m24s · $118.09 · 1.2k in / 768.3k out · claude-code 2.1.296 (claude-opus-4-8)

View session details ↗

Change Summary

  • Adds traceStorage, tracePath, and traceRequired plumbing for local session storage.
  • Implements local-mode storage across trace init, pre-push hooks, trace run, and session-start banners.
  • Adds focused tests, updates CLI docs/Trace README, and preserves stored sessions across cleanup.

AI Session Overall Score

🟢 93% — Clean, well-planned feature work with strong verification and no visible trust erosion.

AI Session Analysis Breakdown

🟢 95% · alignment

🟢 The AI resolved spec ambiguities with the user before coding and then stayed on that path. · High Impact

🟢 95% · context-and-planning

🟢 A detailed plan landed before editing and framed the whole implementation. · High Impact

🟢 95% · verification

🟢 Focused tests and two manual binary checks covered the new local-mode paths. · High Impact

🟢 93% · user-trust-signal

No notes.

🟢 90% · scope-discipline

No notes.

🟢 89% · solution-quality

No notes.


File Attribution

████████████████████ 100% AI / 0% Human

Status Attribution File Lines
created ai app/cli/pkg/action/trace_local_store.go +370 / -0
modified ai app/cli/pkg/action/trace_hook_handler.go +156 / -89
created ai app/cli/pkg/action/trace_local_store_test.go +206 / -0
modified ai app/cli/cmd/root.go +100 / -85
modified ai app/cli/cmd/trace_init.go +153 / -21
created ai app/cli/internal/trace/config/storage_test.go +145 / -0
created ai app/cli/cmd/trace_init_local_test.go +134 / -0
modified ai app/cli/internal/trace/config/config.go +97 / -0
modified ai app/cli/pkg/action/trace_run.go +70 / -20
modified ai app/cli/cmd/trace_run.go +58 / -27
modified ai app/cli/internal/trace/state/state.go +46 / -2
modified ai app/cli/documentation/cli-reference.md +28 / -15
modified ai app/cli/pkg/action/trace_agent_hook.go +31 / -7
modified ai app/cli/internal/trace/state/state_test.go +35 / -0
modified ai app/cli/cmd/trace_hook.go +24 / -1
modified ai app/cli/pkg/action/trace_spec_materials_test.go +12 / -12
modified ai app/cli/pkg/action/trace_spec_materials.go +15 / -4
modified ai app/cli/internal/repositoryconfig/config.go +16 / -1
modified ai app/cli/pkg/action/trace_skills_test.go +6 / -6
modified ai app/cli/internal/trace/README.md +3 / -2
modified ai app/cli/pkg/action/trace_pasted_images_test.go +2 / -2
modified ai app/cli/pkg/action/trace_spec_pointers_test.go +2 / -2
modified ai app/cli/pkg/action/trace_skills_scenario_test.go +1 / -1

Policies (4)

Status Policy Material Messages
✅ Passed ai-config-ai-agents-allowed ai-coding-session-7492b7 -
✅ Passed ai-config-no-dangerous-commands ai-coding-session-7492b7 -
✅ Passed ai-config-no-secrets ai-coding-session-7492b7 -
✅ Passed ai-config-mcp-servers-allowed ai-coding-session-7492b7 -

Security Checks — evaluating

Policies are still being evaluated. Results will update once available.

Scans not applied (3)
Scan Reason
vulnerability-scan no manifest/lockfile changed
github-actions-scan no workflow files changed
iac-scan no IaC files changed

Security context

This change touches code with 6 recorded security-fix advisories. These are pointers to what past fixes established, not findings in this diff, and they never fail the check.

View in Chainloop ↗ · How this works ↗


Powered by Chainloop and Chainloop Trace

Comment thread app/cli/pkg/action/trace_local_store.go Fixed
…ontrol plane

Chainloop Trace records an AI coding session and, on git push, uploads a signed
attestation to a control plane. This adds a local mode where a repository
records its sessions and stores the assembled evidence set on disk, with no
control plane, login, or network.

The storage mode is a committed repository policy in .chainloop.yml: traceStorage
(CONTROL_PLANE default, or LOCAL), an optional tracePath, and traceRequired
(replacing the deprecated requireTrace). `trace init --local` sets a repository
up with no control plane, and the pre-push hook then runs the same push-time
assembly and writes each material under its content digest, with a per-session
manifest that declares the output unsigned and not an attestation. `trace run`
honors the mode as well. The stored evidence equals what a push would upload
apart from the signature, and secret redaction stays on by default with a
CHAINLOOP_TRACE_NO_REDACT opt-out for a trusted local store.

In local mode no control-plane connection is opened: the trace commands build it
lazily and only on the attestation path. The stored sessions are kept across a
trace uninstall.

Refs #3582

Chainloop-Trace-Sessions: 7492b77a-1dc6-4352-823f-f87c42672452

Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
@matiasinsaurralde
matiasinsaurralde force-pushed the matias/issue-3582-trace-local-mode branch from 7be3fc4 to 49b0b6c Compare October 14, 2026 15:07
The local store sized the merged annotations map from len(crafted)+len(extra), which a static analyzer flags as an allocation size that may overflow. Both maps hold only a handful of annotation keys, so the sum cannot overflow in practice; size the hint from one length and let the map grow instead.

Refs #3582

Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
@matiasinsaurralde
matiasinsaurralde marked this pull request as ready for review October 14, 2026 18:23
@matiasinsaurralde
matiasinsaurralde requested a review from a team October 14, 2026 18:23

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 23 files

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread app/cli/internal/trace/config/config.go Outdated
Comment thread app/cli/pkg/action/trace_local_store.go Outdated
Comment thread app/cli/internal/trace/state/state.go
Comment thread app/cli/pkg/action/trace_run.go
Comment thread app/cli/cmd/trace_run.go
Comment thread app/cli/pkg/action/trace_agent_hook.go Outdated
Comment thread app/cli/documentation/cli-reference.md Outdated
Comment thread app/cli/cmd/trace_init.go Outdated
Comment thread app/cli/cmd/trace_init.go Outdated
The local-mode session-start banner now says the session is recorded in
local mode, so a developer can tell at a glance which storage mode is
active without inferring it from the destination line (R-012).

Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
A config read or parse error no longer selects CONTROL_PLANE silently, so
a LOCAL repository with a broken .chainloop.yml cannot have its pre-push hook
upload a local-only session. Local evidence is finalized only when every
gathered session is stored, so a partial failure no longer strands a session's
retry state. A control-plane trace run is reported in telemetry again, while a
local run stays excluded. A trace run that stores its evidence surfaces a store
failure when the wrapped command succeeded. Uninstall preserves a configured
tracePath that resolves inside the trace directory. The local session-start
banner states the storage timing that matches how the session is driven.

Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
…clone guidance

A control-plane trace init over a repository previously set to LOCAL now
writes traceStorage: CONTROL_PLANE, so the pre-push hook stops storing on disk
and the init no longer disagrees with how the repository behaves. The local-mode
next steps now tell each teammate to run chainloop trace init --local after
cloning, since the managed git hooks live under .git and are not cloned, so a
commit alone does not set a clone up to record.

Signed-off-by: Matías Insaurralde <matias@chainloop.dev>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread app/cli/cmd/trace_init.go Outdated
A control-plane trace init now surfaces an invalid traceStorage in an
existing .chainloop.yml before installing hooks, instead of discarding the
error and leaving a repository whose every pre-push fails on the same config.
An explicit --local run still overrides the bad value.

Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
Comment thread app/cli/cmd/trace_init.go
if err != nil {
return err
}
cfg.local = localFlag

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the idea is that once the repository is configured as LOCAL, every session for every user will be local? What's the path to CONTROL_PLANE? do they need to run trace init again (and commit)? It would be good to have it documented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Documented in app/cli/internal/trace/README.md under "Switching storage modes." Fuller treatment (a spec requirement for the lifecycle, and possibly an explicit --control-plane flag to mirror --local) I'd do as a separate follow-up.


// diskMaterialSink implements specMaterialAdder by crafting each material
// offline and writing its content under its content digest. It is the local
// counterpart of AttestationExecutor: it runs the identical crafting — same

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice if both AttestationExecutor and DiskMaterialSink implement the same TraceStorage interface, not only to simplify the logic (removing if-elses), but also opening the option to future storage alternatives.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the material level there's already a common seam: specMaterialAdder (AddMaterial(ctx, name, path, kind, annotations) (digest, error)). Both backends implement it:

  • AttestationExecutor.AddMaterial (trace_attestation.go:219) → uploads to CAS.
  • diskMaterialSink.AddMaterial (trace_local_store.go:240) → writes under the digest on disk.
    The entire assembly (attachSessionEvidence, attachSkills, redaction, digesting) runs against that interface, so it's identical for both and is why a local store reproduces a push byte-for-byte. gatherPushSessions and finalizePush are shared too. So the duplication is already mostly gone; what remains is two thin call-site branches (trace_hook.go:143 and trace_run.go:200) and the two flow functions RunTracePush / RunTraceLocalStore.

The real obstacle to a naive TraceStorage is that the two flows differ in granularity, which is what makes a careless interface leak:

  • Control plane: one attestation envelope for the whole push: executor.Init → attachSessionEvidence(executor, allSessions) → one executor.Push.
  • Local: per-session, a loop, a fresh diskMaterialSink and a manifest.json per session.

Plus they differ in: connection setup (CP needs a lazily-built executor/identity; local needs nothing), the terminal step (signed in-toto envelope vs unsigned manifest), and error semantics (attest only on a clean run; local stores even an interrupted session). A per-session interface method (FinalizeSession) would not map onto the single-envelope CP model.

…ent it

A control-plane trace init that clears a previous LOCAL policy now tells the
user the repository switched and to commit .chainloop.yml, instead of changing
the mode with only a debug log. The trace README documents how to switch storage
modes in both directions and that each clone installs its own hooks.

Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
@migmartri

migmartri commented Oct 14, 2026 •

Copy link
Copy Markdown
Member
   --trace-path string   where local-mode sessions are stored (default: under .git/chainloop-trace/local-sessions); implies --local

I'd NOT make it imply local since in the future we might allow more than one mode

I'd also like to discuss the right default location, would it be

(default: under .git/chainloop-trace/local-sessions);

or should it be .chainloop/... since that's what we do now with stored specs, etc?

@migmartri

Copy link
Copy Markdown
Member

Is this new?

image
Each teammate installs their own git hooks once after cloning, since the hooks
live under .git and are not cloned:
       chainloop trace init --local

what's that command for? isn't supposed to be already initialized

I'd also extend this error message saying that alternatively you can pass --local to do it offline.

chainloop trace init
ERR authentication required, please run "chainloop auth login"

in fact I am starting to think that that maybe the flag should be called --offline :)

@migmartri

Copy link
Copy Markdown
Member
Congratulations, your repository is initialized in local mode

Maybe here we can add, if in the future you wna to connect this repo to a chainloop control plane, run trace init again

@migmartri

Copy link
Copy Markdown
Member

Lastly, it's not working for me, after initialing,

image

I get this

diff --git a/.chainloop.yml b/.chainloop.yml
index 6c4097ad..9adc8a5a 100644
--- a/.chainloop.yml
+++ b/.chainloop.yml
@@ -5,10 +5,9 @@ projectName: chainloop
 # This indicates the [current version]+next
 # to indicate that we are building a new version of the project
 projectVersion: v1.123.0+next
-
 # Experimental feature used by Chainloop labs shared workflow https://github.com/chainloop-dev/labs
 # It maps the material names with location in disk so they get automatically attested
-
 scorecards:
   - name: sarif-results
     path: metadata/results.sarif
+traceStorage: LOCAL
diff --git a/.claude/settings.json b/.claude/settings.json
index 29a2edf6..ddbdce13 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -17,7 +17,7 @@
             "type": "command"
           }
         ],
-        "matcher": "Edit|Write|MultiEdit|Bash"
+        "matcher": "Edit|Write|MultiEdit|Bash|Skill"
       }
     ],
     "PostToolUseFailure": [
diff --git a/.opencode/plugins/chainloop-trace.ts b/.opencode/plugins/chainloop-trace.ts
index f23a9d01..2eb4e23d 100644
--- a/.opencode/plugins/chainloop-trace.ts
+++ b/.opencode/plugins/chainloop-trace.ts
@@ -2,6 +2,22 @@ import { spawn } from "node:child_process"

 const fileWritingTools = ["edit","write","apply_patch","patch"]
 const commandTools = ["bash","shell"]
+const skillTool = "skill"
+
+// skillDirFrom returns the folder of the skill that a skill tool call loaded:
+// the dir field of the result metadata, or else the base directory line of
+// the result text. Each OpenCode major gives the result in its own shape, so
+// each candidate is tried in turn.
+function skillDirFrom(...results: any[]): string {
+  for (const r of results) {
+    const dir = r?.metadata?.dir
+    if (typeof dir === "string" && dir) return dir
+    const text = typeof r === "string" ? r : typeof r?.output === "string" ? r.output : ""
+    const m = /^Base directory for this skill:\s*(.+)$/m.exec(text)
+    if (m) return m[1].trim()
+  }
+  return ""
+}

 function filePathsFromArgs(args: any): string[] {
   if (args?.filePath) return [args.filePath]
@@ -117,8 +133,14 @@ async function sessionEvent(directory: string, type: string, sessionID: string,
   }
 }

but the message says that I am going to send it to chainloop controlplane, not local

image

this is my chainloop.yaml


  Press Ctrl-C again to exit                                                                                                                                                      ◐ medium · /effort
maximus ➜  chainloop git:(main) ✗ chainloop wf ls
ERR authentication required, please run "chainloop auth login"
maximus ➜  chainloop git:(main) ✗ cat .chainloop.yml
# organization and projectName are used for Chainloop Trace
# https://docs.chainloop.dev/reference/operator/trace
organization: chainloop
projectName: chainloop
# This indicates the [current version]+next
# to indicate that we are building a new version of the project
projectVersion: v1.123.0+next
# Experimental feature used by Chainloop labs shared workflow https://github.com/chainloop-dev/labs
# It maps the material names with location in disk so they get automatically attested
scorecards:
  - name: sarif-results
    path: metadata/results.sarif
traceStorage: LOCAL

@migmartri migmartri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matiasinsaurralde ptal at my comments, I can't make it work as well as I added some additional feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants